تست بار API با Locust برای ASP.NET Core

تست بار یعنی ترافیک روز اوج را پیش از رسیدنش با کاربران شبیه‌سازی‌شده بسازید و ببینید API کجا کند می‌شود یا خطا می‌دهد. Locust رفتار کاربر را با کد Python توصیف می‌کند،‏ پس می‌شود یک جریان واقعی مثل ورود،‏ خواندن و ارسال را روی یک API نوشته‌شده با ASP.NET Core اجرا کرد. گزینه‌های این راهنما با مستندات Locust 2.46 تطبیق داده شده‌اند؛‏ نسخه‌های فعلی Locust به Python 3.11 یا بالاتر نیاز دارند.

چرا پیش از یک اوج مشخص؟

بعضی اوج‌ها را از قبل می‌شود دید: روز آزمون،‏ شروع ثبت‌نام یا یک کمپین. در «گزینه دو» پیش از اوج آزمون‌ها،‏ که ۴۵ تا ۷۰ هزار کاربر هم‌زمان داشتند،‏ با تست بار Locust گلوگاه‌ها را پیدا می‌کردم و بعد کوئری‌های SQL Server،‏ مسیرهای کش و APIهای پرتکرار را بهینه می‌کردم. مشکلی که یک هفته پیش از آزمون پیدا شود فرصت اصلاح و تست دوباره دارد؛‏ در روز آزمون این فرصت نیست.

پیش از نوشتن کد،‏ دو چیز را روشن کنید:

  • بار هدف. تعداد کاربران هم‌زمان و سهم هر اندپوینت را از دادهٔ واقعی دربیاورید: تعداد ثبت‌نامی‌ها یا لاگ اوج قبلی.
  • معیار قبولی. مثلاً «در بار هدف،‏ صدک ۹۵ هر اندپوینت زیر فلان میلی‌ثانیه و نرخ خطا زیر فلان درصد بماند». بدون معیار،‏ هر نتیجه‌ای را می‌شود خوب یا بد تفسیر کرد.

حجم داده در محیط تست هم باید به محیط عملیاتی نزدیک باشد؛‏ کوئری‌ای که روی ده هزار ردیف سریع است،‏ روی ده میلیون ردیف شاید نباشد.

نوشتن یک locustfile واقعی

locustfile یک ماژول معمولی Python است. هر کلاسی که از HttpUser ارث ببرد یک نوع کاربر است و Locust برای هر کاربر شبیه‌سازی‌شده یک نمونه از آن می‌سازد. نمونهٔ زیر دانش‌آموزی است که وارد می‌شود،‏ سؤال می‌گیرد،‏ پاسخ می‌فرستد و گاهی نتیجه‌اش را می‌بیند. اندپوینت‌ها و عددها نمونه‌اند:

import csv
import itertools
import os
import random

from locust import HttpUser, between, task
from locust.exception import StopUser

with open("test_users.csv", newline="", encoding="utf-8") as f:
    ACCOUNTS = itertools.cycle(list(csv.DictReader(f)))

EXAM_IDS = [1201, 1202, 1203]


class Student(HttpUser):
    wait_time = between(3, 12)

    def on_start(self):
        account = next(ACCOUNTS)
        with self.client.post("/api/auth/login", name="login", catch_response=True,
                              json={"userName": account["user"],
                                    "password": os.environ["LOADTEST_PASSWORD"]}) as resp:
            if resp.status_code != 200:
                resp.failure(f"login: HTTP {resp.status_code}")
                raise StopUser()
            token = resp.json()["accessToken"]
        self.client.headers["Authorization"] = f"Bearer {token}"
        self.exam_id = random.choice(EXAM_IDS)

    @task(6)
    def next_question(self):
        self.client.get(f"/api/exams/{self.exam_id}/questions/next",
                        name="/api/exams/[id]/questions/next")

    @task(3)
    def submit_answer(self):
        with self.client.post(f"/api/exams/{self.exam_id}/answers",
                              name="/api/exams/[id]/answers", catch_response=True,
                              json={"choice": random.randint(1, 4)}) as resp:
            if resp.status_code == 200 and resp.json().get("saved") is not True:
                resp.failure("200 but not saved")

    @task(1)
    def my_results(self):
        self.client.get("/api/me/results")

وزن تسک‌ها و زمان انتظار

متدهایی که @task دارند تسک‌اند. هر کاربر بعد از هر تسک،‏ تسک بعدی را تصادفی انتخاب می‌کند و عدد داخل پرانتز وزن آن است: این‌جا گرفتن سؤال شش برابر دیدن نتیجه و دو برابر ارسال پاسخ انتخاب می‌شود.

between(3, 12) یعنی هر کاربر بعد از هر تسک ۳ تا ۱۲ ثانیه مکث می‌کند،‏ مثل دانش‌آموزی که سؤال را می‌خواند. برای نرخ ثابت،‏ constant_throughput و constant_pacing هم هست. این توابع روی تسک اثر دارند نه درخواست،‏ و فقط نرخ را محدود می‌کنند: اگر پاسخ‌ها کند شوند،‏ کاربر تازه‌ای نمی‌سازند.

ورود در on_start

Locust متد on_start را وقتی هر کاربر شروع به کار می‌کند یک بار صدا می‌زند؛‏ جای ورود و گرفتن توکن همین‌جاست. client هر کاربر یک HttpSession است که از requests.Session ارث می‌برد،‏ پس هدری که روی self.client.headers بگذارید در درخواست‌های بعدی همان کاربر فرستاده می‌شود. حساب‌های تست از فایل خوانده می‌شوند تا همه با یک حساب وارد نشوند و رمزشان از متغیر محیطی می‌آید،‏ نه از کد. اگر ورود شکست بخورد،‏ خود درخواست ورود شکست ثبت می‌شود و StopUser آن کاربر را متوقف می‌کند تا آمار با خطاهای 401 پر نشود. Locust این را با traceback در لاگ می‌نویسد و کاربری جایش نمی‌سازد،‏ پس تعداد کاربران فعال کم می‌شود.

گروه‌بندی با name و قاعدهٔ شکست با catch_response

Locust آمار را بر اساس نام درخواست جمع می‌کند که پیش‌فرضش URL است؛‏ اگر شناسه در مسیر باشد،‏ هر شناسه یک ردیف جدا می‌سازد. آرگومان name همهٔ آن درخواست‌ها را زیر یک برچسب مثل /api/exams/[id]/answers جمع می‌کند.

به‌طور پیش‌فرض هر پاسخ با کد کمتر از ۴۰۰ موفق است و 4xx و 5xx شکست. اما API ممکن است ۲۰۰ برگرداند و کار را انجام نداده باشد. با catch_response=True و یک بلوک with خودتان تصمیم می‌گیرید: resp.failure() درخواست را شکست ثبت می‌کند و resp.success() حتی یک کد خطا را موفق حساب می‌کند. شکست‌ها بر اساس متن پیام گروه‌بندی می‌شوند،‏ پس پیام را دقیق بنویسید. اگر داخل بلوک استثنای دیگری رخ دهد،‏ مثلاً بدنهٔ پاسخ JSON نباشد،‏ آن درخواست فقط در فهرست استثناها ثبت می‌شود،‏ نه در آمار درخواست‌ها؛‏ پس خطای JSON را بگیرید و با resp.failure() ثبت کنید.

اجرای بدون رابط وب و گزارش‌ها

رابط وب برای امتحان سناریو راحت است،‏ اما برای اجرای تکرارپذیر،‏ تست را با --headless و پارامترهای ثابت اجرا کنید تا نتیجه‌ها قابل مقایسه باشند:

locust -f exam_day.py --headless -H https://staging.example.com \
       -u 3000 -r 50 -t 30m --csv exam_day --html exam_day.html
  • -u بیشترین تعداد کاربر هم‌زمان است و -r تعداد کاربری که در هر ثانیه اضافه می‌شود؛‏ این‌جا رسیدن به ۳۰۰۰ کاربر یک دقیقه طول می‌کشد. برای آزمونی که همه با هم شروع می‌کنند،‏ همین شیب تند بخشی از سناریوست.
  • -t مدت اجراست و از شروع تست حساب می‌شود،‏ نه از پایان بالا رفتن بار.
  • --csv exam_day فایل‌های exam_day_stats.csv (آمار کل هر اندپوینت)،‏ exam_day_failures.csv،‏ exam_day_exceptions.csv و exam_day_stats_history.csv را می‌سازد. فایل history در طول اجرا ردیف تازه می‌گیرد؛‏ به‌طور پیش‌فرض فقط برای جمع کل،‏ و با --csv-full-history برای هر اندپوینت.
  • --html یک گزارش HTML با نمودار و جدول می‌نویسد که برای پیوست تیکت یا مقایسهٔ دو اجرا مناسب است.

اگر حتی یک درخواست شکست بخورد،‏ Locust با کد خروج ۱ تمام می‌شود (قابل تغییر با --exit-code-on-error) و همین اجرای تست در CI/CD را ساده می‌کند. --reset-stats آمار را یک بار،‏ پس از ساخته شدن همهٔ کاربران،‏ صفر می‌کند تا دورهٔ بالا رفتن بار در نتیجهٔ نهایی نیاید (در اجرای توزیع‌شده آن را به master و workerها هر دو بدهید). برای سناریوی آزمون،‏ همان دوره را جداگانه در فایل history نگاه کنید.

وقتی یک ماشین کافی نیست: اجرای توزیع‌شده

هر پروسهٔ Python به‌خاطر GIL عملاً یک هستهٔ CPU را به کار می‌گیرد. اگر مصرف CPU پروسهٔ Locust از ۹۰ درصد بگذرد،‏ هشدار CPU usage above 90% را در لاگ می‌نویسد؛‏ از آن لحظه عددها بیشتر محدودیت مولد بار را نشان می‌دهند تا رفتار API را.

در حالت توزیع‌شده،‏ یک پروسهٔ master تست را هماهنگ و آمار را جمع می‌کند و پروسه‌های worker کاربران را اجرا می‌کنند. روی یک ماشین،‏ locust --processes 4 یک master و چهار worker بالا می‌آورد و --processes -1 به تعداد هسته‌های منطقی worker می‌سازد؛‏ این گزینه به fork() متکی است و روی ویندوز کار نمی‌کند. برای چند ماشین،‏ دستور اول روی master و دستور دوم روی هر کدام از دو ماشین تولید بار اجرا می‌شود:

locust -f exam_day.py --master --headless --expect-workers 8 \
       -H https://staging.example.com -u 12000 -r 200 -t 30m --csv exam_day

locust -f - --worker --master-host 10.0.0.10 --processes 4
  • --expect-workers تعداد پروسه‌های worker است،‏ نه ماشین‌ها: دو ماشین با چهار پروسه یعنی ۸. master تا وصل شدن همه صبر می‌کند.
  • -f - locustfile را از master می‌گیرد،‏ اما فقط یک فایل تکی را؛‏ فایل‌های داده مثل test_users.csv باید روی هر ماشین باشند. هر پروسه فهرست حساب‌ها را از اول می‌خواند،‏ پس اگر حساب تکراری مشکل است،‏ حساب‌ها را بین پروسه‌های worker تقسیم کنید،‏ نه فقط بین ماشین‌ها.
  • workerها به‌طور پیش‌فرض از پورت 5557 به master وصل می‌شوند.
  • پیش از اضافه کردن ماشین،‏ FastHttpUser را امتحان کنید: طبق مستندات Locust زمان CPU بسیار کمتری مصرف می‌کند. پاسخ‌ها را سریع‌تر نمی‌کند،‏ فقط مولد بار را سبک‌تر. API آن شبیه است ولی یکسان نیست؛‏ مثلاً self.client.headers ندارد و هدر توکن را باید در هر درخواست فرستاد.

خواندن نتیجه: RPS،‏ میانه،‏ صدک‌ها و شکست‌ها

جدول Locust برای هر اندپوینت تعداد درخواست و شکست،‏ میانگین،‏ کمینه،‏ بیشینه،‏ میانه و درخواست در ثانیه (RPS) را نشان می‌دهد و در پایان،‏ جدول صدک‌ها را. RPS توان عملیاتی واقعی است؛‏ اگر کاربر اضافه می‌کنید و RPS بالا نمی‌رود،‏ سیستم یا خود مولد بار به سقف رسیده است. میانه تجربهٔ یک درخواست معمولی است و صدک ۹۵ و ۹۹ دنبالهٔ کند را نشان می‌دهند: ۵ و ۱ درصد درخواست‌ها از آن‌ها کندترند. همهٔ این‌ها فقط کنار نرخ شکست معنا دارند.

میانگین گمراه‌کننده است،‏ چون چند درخواست خیلی کند آن را بالا می‌کشد بی‌آن‌که شکل توزیع را نشان دهد. برای مثال اگر از ۱۰۰ درخواست،‏ ۹۰ تا ۲۰ میلی‌ثانیه و ۱۰ تا ۲ ثانیه طول بکشند،‏ میانگین ۲۱۸ میلی‌ثانیه است؛‏ عددی که هیچ درخواستی تجربه‌اش نکرده. میانه ۲۰ میلی‌ثانیه است و صدک ۹۵ دو ثانیه؛‏ مشکل واقعی فقط در صدک‌ها دیده می‌شود.

دو نکتهٔ دقیق‌تر: Locust برای محاسبهٔ صدک‌ها زمان‌های بالای ۱۰۰ میلی‌ثانیه را تقریباً به دو رقم معنادار گرد می‌کند (۱۴۷ میلی‌ثانیه را ۱۵۰ ثبت می‌کند)،‏ پس صدک‌هایش تقریبی‌اند. همچنین مدل بار Locust بسته است: هر کاربر تا پاسخ نرسد درخواست بعدی را نمی‌فرستد،‏ پس وقتی سرور کند می‌شود نرخ ارسال هم پایین می‌آید و بازهٔ کند با نمونه‌های کمتری در آمار می‌آید. به این اثر coordinated omission می‌گویند؛‏ اگر کاربران واقعی مستقل از هم می‌رسند و منتظر پاسخ دیگران نمی‌مانند،‏ صدک‌های Locust ممکن است خوش‌بینانه‌تر از تجربهٔ آن‌ها باشد.

مستندات Locust الگوی اشباع را این‌طور توصیف می‌کند: کاربر اضافه می‌شود،‏ اما RPS دیگر بالا نمی‌رود و زمان پاسخ به‌سرعت زیاد می‌شود. هنگام گزارش هم شرایط را کنار عدد بنویسید. بنچمارک Locust سرویس دریافت رویدادهای زمان تماشا (یک minimal API روی .NET 10) روی یک ایستگاه کاری ۱۲ هسته‌ای،‏ با ۱۰٬۰۰۰ رویداد و ۵۰ کاربر هم‌زمان،‏ حدود ۲٬۴۵۰ درخواست در ثانیه،‏ میانهٔ ۱۳ و صدک ۹۵ برابر ۲۱ میلی‌ثانیه نشان داد و حافظهٔ سرویس در اوج حدود ۱۰۸ مگابایت بود. چنین عددهایی برای مقایسهٔ دو نسخه از همان کد روی همان ماشین معنا دارند،‏ نه برای پیش‌بینی ظرفیت سرور عملیاتی.

پیدا کردن گلوگاه: سمت سرور را هم ببینید

Locust می‌گوید کدام اندپوینت کند شده،‏ نه این‌که چرا. روش من کوتاه است: بار را پله‌پله زیاد کنید تا به اشباع برسید و در همان لحظه ببینید کدام منبع پر شده است. اگر CPU پروسهٔ API نزدیک ۱۰۰ درصد است،‏ گلوگاه احتمالاً در خود پردازش اپلیکیشن است؛‏ اگر CPU پایین است و زمان پاسخ بالا،‏ درخواست‌ها منتظر چیزی‌اند. بعد فقط یک چیز را عوض کنید،‏ همان سناریو را با همان داده دوباره اجرا کنید و فایل‌های CSV دو اجرا را کنار هم بگذارید.

در سمت .NET،‏ dotnet-counters بدون تغییر کد شمارنده‌های زندهٔ پروسه را نشان می‌دهد. دستور اول شناسهٔ پروسه را پیدا می‌کند (این‌جا 4312):

dotnet-counters ps
dotnet-counters monitor -p 4312 --counters System.Runtime,Microsoft.AspNetCore.Hosting,Microsoft.Data.SqlClient.EventSource
  • Thread pool. در .NET 9 و بعد dotnet.thread_pool.thread.count و dotnet.thread_pool.queue.length را ببینید (در .NET 8 با نام‌های ThreadPool Thread Count و ThreadPool Queue Length). طبق مستندات Microsoft،‏ نشانهٔ گرسنگی thread pool افزایش آهسته و پیوستهٔ رشته‌ها،‏ حدود یکی دو رشته در ثانیه،‏ همراه با CPU خیلی کمتر از ۱۰۰ درصد است. رایج‌ترین علتش کد sync-over-async است،‏ مثل .Result یا .Wait().
  • Connection pool. شمارنده‌های SqlClient (از نسخهٔ 3.0 کتابخانهٔ Microsoft.Data.SqlClient) اتصال‌های فعال و آزاد pool را نشان می‌دهند (number-of-active-connections و number-of-free-connections). پیش‌فرض Max Pool Size برای هر pool برابر ۱۰۰ است؛‏ وقتی همه در استفاده باشند،‏ باز کردن اتصال تا Connect Timeout (پیش‌فرض ۱۵ ثانیه) صبر می‌کند و بعد خطا می‌دهد. یک قاعدهٔ سرانگشتی،‏ نه اثبات: اگر Connect Timeout را عوض نکرده‌اید و دسته‌ای از پاسخ‌ها حدود ۱۵ ثانیه طول کشیده‌اند،‏ به تمام شدن pool مشکوک شوید و پیام timeout را در لاگ اپلیکیشن ببینید. پیش از بالا بردن سقف،‏ نشت اتصال،‏ کوئری‌های کند و تراکنش‌های مسدودشده را بررسی کنید.
  • SQL Server. حین تست،‏ sys.dm_exec_requests نشان می‌دهد هر درخواست منتظر چیست (wait_type) و چه نشستی مسدودش کرده (blocking_session_id). اگر Query Store روشن باشد،‏ از SQL Server 2017 به بعد waitهای هر کوئری را به تفکیک دسته ثبت می‌کند. آمار sys.dm_os_wait_stats از آخرین راه‌اندازی جمع می‌شود،‏ پس قبل و بعد از تست از آن برش بگیرید. LCK_M_* یعنی انتظار برای قفل،‏ PAGEIOLATCH_* انتظار برای خواندن از دیسک،‏ و ASYNC_NETWORK_IO یعنی SQL Server منتظر است اپلیکیشن نتیجه را تحویل بگیرد؛‏ در این حالت گلوگاه گاهی در خود اپلیکیشن است. جزئیات بیشتر در راهنمای کارایی SQL Server آمده است.
  • Redis. SLOWLOG GET فرمان‌های کند را نشان می‌دهد (فقط زمان اجرا روی سرور،‏ بدون شبکه). پیام timeout در StackExchange.Redis آمار thread pool را هم دارد (بخش WORKER با Busy و Min)؛‏ اگر Busy از Min بیشتر باشد،‏ رشتهٔ تازه با تأخیر اضافه می‌شود و ریشهٔ timeout ممکن است در اپلیکیشن باشد،‏ نه در Redis.

اشتباه‌های رایج

  • اجرای Locust روی ماشین API در تست ظرفیت. مولد بار و API سر CPU رقابت می‌کنند و مسیر واقعی شبکه و load balancer هم دور زده می‌شود. برای مقایسهٔ دو نسخه از یک کد اشکالی ندارد،‏ به شرطی که همین‌طور گزارش شود و از آن ظرفیت سرور عملیاتی را نتیجه نگیرید.
  • شروع سرد. درست بعد از استقرار،‏ بسیاری از متدها هنوز JIT نشده‌اند،‏ کش‌ها خالی‌اند و poolها تازه دارند بزرگ می‌شوند. اگر رفتار پایدار را می‌سنجید،‏ اول یک اجرای کوتاه با بار کم بگیرید؛‏ اگر خود لحظهٔ هجوم را می‌سنجید،‏ مثل شروع آزمون،‏ شروع سرد بخشی از سناریوست.
  • دادهٔ غیرواقعی. اگر همه با یک حساب وارد شوند یا یک آزمون را باز کنند،‏ یا همه‌چیز از کش می‌آید یا همه سر یک ردیف قفل می‌گیرند. پراکندگی شناسه‌ها و حجم داده باید به محیط عملیاتی نزدیک باشد.
  • نادیده گرفتن 4xx و 5xx. پاسخ خطا معمولاً از پاسخ درست ارزان‌تر است،‏ پس RPS بالا و زمان پاسخ پایین به نظر می‌رسد در حالی که کاربر چیزی نگرفته است. موارد رایج: 401 وقتی توکن در تست طولانی منقضی می‌شود؛‏ rate limiter در ASP.NET Core که به‌طور پیش‌فرض 503 برمی‌گرداند و اگر بر اساس IP پارتیشن شده باشد،‏ کاربران Locust را که همه از چند IP می‌آیند در یک سهمیه می‌اندازد؛‏ و 503 عمدی،‏ مثل سرویس زمان تماشا که وقتی کانال حافظه‌ای‌اش پر است درخواست را رد می‌کند. برای این آخری با catch_response پیام شکست جداگانه بگذارید.
  • نبودن زمان انتظار. بدون wait_time،‏ تسک بعدی بلافاصله اجرا می‌شود و «۳۰۰۰ کاربر» دیگر با ۳۰۰۰ دانش‌آموز واقعی قابل مقایسه نیست.

نمونه‌کار دسترس‌پذیری بالا در اوج آزمون‌ها زمینهٔ تست‌های پیش از اوج را شرح می‌دهد و جزئیات بنچمارک بالا در یادداشت زمان تماشا آمده است. اگر می‌خواهید سیستمی را پیش از یک اوج ترافیک آماده کنید،‏ در صفحهٔ همکاری توضیح داده‌ام چه کمکی از من برمی‌آید.

پرسش‌های رایج

Locust چیست؟

Locust ابزار متن‌باز تست بار است که رفتار کاربران را با کد Python توصیف می‌کند. هر کاربر شبیه‌سازی‌شده سناریوی خودش را اجرا می‌کند و Locust برای هر اندپوینت تعداد درخواست،‏ شکست‌ها،‏ RPS و صدک‌های زمان پاسخ را گزارش می‌دهد.

آیا برای تست API نوشته‌شده با .NET هم می‌شود از Locust استفاده کرد؟

بله. Locust از بیرون و از طریق HTTP با API کار می‌کند،‏ پس زبان و فریم‌ورک سمت سرور برایش فرقی ندارد. فقط خود سناریو با Python نوشته می‌شود.

spawn rate در Locust یعنی چه؟

تعداد کاربری است که Locust در هر ثانیه اضافه می‌کند تا به تعداد نهایی برسد. بار نهایی را عوض نمی‌کند و فقط سرعت رسیدن به آن را تعیین می‌کند؛‏ برای رویدادی که همه با هم شروع می‌کنند،‏ شیب تند بخشی از سناریوست.

چرا به‌جای میانگین زمان پاسخ به صدک ۹۵ نگاه کنیم؟

میانگین با چند درخواست خیلی کند بالا می‌رود و شکل توزیع را پنهان می‌کند. صدک ۹۵ نشان می‌دهد کندترین ۵ درصد درخواست‌ها از چه عددی کندترند و کاربران همین دنبالهٔ کند را حس می‌کنند.

کی باید Locust را توزیع‌شده اجرا کرد؟

وقتی Locust هشدار مصرف CPU بالای ۹۰ درصد می‌دهد یا یک پروسه بار لازم را نمی‌سازد. هر پروسهٔ Locust عملاً یک هستهٔ CPU را به کار می‌گیرد،‏ پس معمولاً به ازای هر هسته یک worker اجرا می‌کنند و در صورت نیاز ماشین اضافه می‌کنند.

در همین بخش

راهنمای بک‌اند