Інструменти тестування продуктивності. Частина 2

💡 Усі статті, обговорення, новини про тестування — в одному місці. Приєднуйтесь до QA спільноти!

Всім привіт. Я Сергій Моренець, розробник, викладач, спікер і технічний письменник, хочу поділитися з вами своїм досвідом роботи з такою цікавою темою, як тестування ефективності або продуктивності застосункiв. У першій частині цієї статті я зробив огляд найпопулярніших технологій, розповів про їхню історію створення та основну функціональність.

Тепер перевіримо заявлені характеристики розглянутих технологій на практиці. Візьмемо 4 найцікавіші та найперспективніші технології: JMeter, Gatling, K6 та Wrk. У цій частині я торкнуся Wrk і K6, тим більше що за останні роки у нас накопичилося достатньо досвіду роботи з цими системами, і ми розглядаємо ці технології на деяких наших тренінгах. Сподіваюся, що ця стаття буде корисною всім, хто планує займатися тестуванням ефективності.

Підготовка до тестування

Перша мета тестування — порівняти показники, метрики та зручність роботи. Але є ще й друга мета — порівняти вебфреймворки, які використовуються у нашому проєкті: Spring MVC, Jersey та Micronaut. Було б цікаво перевірити швидкодію їх REST API та її реалізації.

Performance testing — один із небагатьох способів знайти проблеми у продуктивності і, до речі, у цій частині ми одну таку проблему знайдемо. Але про все по порядку.

Для порівняння напишемо два прості REST-сервіси:

  1. Повернення даних через запит GET.
  2. Повернення даних із деякою затримкою (наприклад, 1 секунда).

По суті, другий варіант симулює реальний REST-сервіс із зверненням до зовнішнього джерела даних (СУБД) з очікуванням відповіді та блокуванням поточного потоку. Як опціональний параметр запиту будемо передавати затримку (у мілісекундах).

Почнемо з Spring MVC:

@RestController
@RequestMapping("benchmark")
public class BenchmarkController {
      
       @GetMapping
       public List<BenchmarkDTO> findAll(@RequestParam(name = "delay", required = false) Integer delay) throws InterruptedException {
              if(delay != null) {
                     Thread.sleep(delay);
              }
              return List.of(new BenchmarkDTO(1, "Performance"));
       }
      
       @Getter
       @Setter
       @AllArgsConstructor
       @NoArgsConstructor
       public static final class BenchmarkDTO {
             
              private int id;
             
              private String text;
       }
 }

Другий REST-сервіс для Jersey (Jakarta RESTful Web Services):

@Path("benchmark")
public class BenchmarkResource {
      
       @GET
       @Produces(MediaType.APPLICATION_JSON)
       public List<BenchmarkDTO> findAll(@QueryParam("delay") Integer delay) throws InterruptedException {
              if(delay != null) {
                     Thread.sleep(delay);
              }
              return List.of(new BenchmarkDTO(1, "Performance"));
       }
}

І третій REST-сервіс для Micronaut застосунка:

@Controller
public class BenchmarkController {
      
       @Get("/benchmark")
       public List<BenchmarkDTO> findAll(@QueryValue(value = "delay", defaultValue = "0") Integer delay) throws InterruptedException {
              if(delay != 0) {
                     Thread.sleep(delay);
              }
              return List.of(new BenchmarkDTO(1, "Performance"));
       }
}

Кожен застосунок буде запускатися як контейнер Docker. Цим ми маємо закрити дві мети:

  1. Кожен застосунок отримає однакову кількість ресурсів.
  2. Docker дозволяє відстежувати використання системних ресурсів (пам’ять, CPU, I/O), тому можна буде проконтролювати їхню витрату.

Для тестування використовується наступна конфігурація:

  • JDK 17.0.2;
  • Intel Core i9;
  • 32 GB пам’яті;
  • Windows 10;
  • JMeter 5.6;
  • K6 0.45;
  • Gatling 3.9.5;
  • Wrk 4.2.0;
  • Spring Boot 3.0.5 на базі Tomcat 10;
  • Jersey 3.1.1 на базі Tomcat 10;
  • Micronaut 3.8.7;
  • Для JSON серіалізації у всіх фреймворках використовувався Jackson.

Wrk

Тепер переходимо до стадії конфігурації наших performance tools. І тут з’ясовується цікава деталь. Не існує окремого дистрибутива Wrk/Wrk 2. Більш того, Wrk не підтримується на Windows системах. Таким чином, його можна запускати тільки на Linux/MacOS, а у випадку з Wrk 2 його необхідно ще й зібрати вручну з вихідних.

Створимо нову папку docker-scripts/benchmark у корені нашого проєкту та додамо до неї wrk.dockerfile:

 
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y wrk

Image збирається успішно, але при запуску wrk отримуємо помилку:

Servname not supported for ai_socktype

В результаті дослідження виявляється, що потрібно додатково встановити пакет netbase, який забезпечує базові можливості для роботи з TCP/IP і мережевими конфігураційними файлами. Так що підсумковий Dockerfile виглядає так:

 FROM ubuntu:22.04
 RUN apt-get update && apt-get install -y wrk && apt-get install -y --no-install-recommends netbase

Тепер можна розпочати тестування. Візьмемо спочатку Wrk як найпростіший варіант. Оскільки загальна кількість тестових комбінацій перевищує всі розумні межі, ми вчинимо так:

  1. Протестуємо Wrk на всіх трьох фреймворках (щоб порівняти їхню продуктивність).
  2. Потім протестуємо інші performance tools на якомусь одному фреймворку (наприклад, Spring MVC, щоб вже порівняти ефективність та зручність кожної технології).
  3. Виберемо один фреймворк і протестуємо його в режимі прогріву (тобто коли застосунок тільки стартував) і вже прогріте (по суті, аналог production). Це для того, щоб перевірити, чи варто взагалі використовувати прогрів або можна відразу запускати benchmark.
  4. Також протестуємо наші REST-сервіси із включеною затримкою відповіді (1 секунда) для емуляції реального застосунку. Це буде дійсно емуляція, тому що при очікуванні не буде витрати пам’яті та завантаження процесора, але це дозволить перевірити роботу нашого вебсервера з ресурсами (з’єднаннями).

Разом буде 7 комбінацій тестування. Усього ми будемо вимірювати 6 характеристик:

  1. Latency (у мілісекундах).
  2. Кількість запитів за секунду.
  3. Завантаження CPU (%) контейнера, де запущено performance tool.
  4. Кількість пам’яті (у мегабайтах) контейнера, де запущено performance tool.
  5. Завантаження CPU (%) контейнера, де запущено застосунок.
  6. Кількість пам’яті (у мегабайтах) контейнера, де запущено наш застосунок.

Для Wrk будемо використовувати 4 потоки та 20 секунд як інтервал тестування. Перша таблиця показує роботу Wrk для Spring MVC застосунку в режимі прогріву:


Users


Latency (ms)


Req/sec


CPU, Wrk


CPU, App


Mem, Wrk


Mem, App


100


2.13


19140


291


1154


7.2


713


1000


30.2


18460


284


1244


24


883


5000


72.8


15260


284


1207


74


1020

Відразу виникає питання, а чому завантаження процесора більше 100%? Як виявилося, це не помилка, а особливості підрахунку статистики в Docker, коли для багатоядерних систем завантаження може бути максимум N*100%, де N — кількість ядер. Це не буде для нас перешкодою, тому що нам важливо не абсолютне значення, а порівняння цього значення для різних технологій.

Друга таблиця показує роботу Wrk для Spring MVC застосунку після прогріву:


Users


Latency (ms)


Req/sec


CPU, Wrk


CPU, App


Mem, Wrk


Mem, App


100


1.55


22910


295


1192


7


712


1000


9.7


23290


280


1217


12


844


5000


58.44


21980


299


1220


68


1154

Далі спробуємо протестувати Wrk та включимо затримку відповіді в 1с. Це відразу ж позначилося на кількості помилок у запитах (92%) для 5000 користувачів. При цьому кожну секунду оброблялося 130 запитів.

У чому причина цього? По-перше, Wrk за замовчуванням використовує таймаут 2 секунди, що прийнятно для статичних ресурсів, але може бути недостатньо для динамічних запитів, пов’язаних із завантаженням даних. Таймаут можна змінити опцією --timeout, але навіть для 5 секунд відсоток помилок 78%.

Проблема в тому, що при синхронній роботі нашого застосунку кожен запит обробляється в окремому потоці, а в Tomcat 10 за замовчуванням параметр maxThreads (кількість потоків у пулі) дорівнює 200. З цього випливає два висновки:

  1. Обов’язково вказуєте в тестах той тайм-аут, який дорівнює максимально допустимому latency у вашому проєкті.
  2. Для реального тестування змінюйте максимальну кількість потоків (з’єднань) для вашого вебсервера.

Перейдемо до Jersey. Під час тестування виявилася дивна річ. Latency навіть на невеликій кількості користувачів становило 350 мілісекунд, що в 100 (!) разів більше, ніж у Spring MVC. Навіть затяті шанувальники Spring технологій не повірять, що він настільки швидше працює.

У чому ж справа? Спочатку була підозра, що дефолтне значення параметра delay дорівнює 0, а не null, що може вплинути на швидкодію:

        @GET
       @Produces(MediaType.APPLICATION_JSON)
       public List<BenchmarkDTO> findAll(@QueryParam("delay") Integer delay) throws InterruptedException {
              if(delay != null) {
                     Thread.sleep(delay);
              }

Але це виявилося неправильним. В результаті подальшого дослідження було виявлено, що якщо надсилати дані клієнту як колекцію immutable:

       return List.of(new BenchmarkDTO(1, "Spring MVC"));

або

       return Collections.singletonList(new BenchmarkDTO(1, "Spring MVC"));

це призводить до описаної проблеми. А ось використання mutable колекції виправляє цю ситуацію:

 return new ArrayList<>(List.of(new BenchmarkDTO(1, "Performance")));

Я створив тикет на цю проблему, сподіватимемося, що її досліджують та виправлять. У будь-якому разі без performance testing ми навряд чи змогли б її виявити.

Третя таблиця показує роботу Wrk для Jersey застосунку після прогріву:


Users


Latency (ms)


Req/sec


CPU, Wrk


CPU, App


Mem, Wrk


Mem, App


100


1.67


20500


270


1144


6.7


1196


1000


10.37


23390


247


1170


16


1137


5000


57.42


21760


263


1204


70


1779

Тепер перейде до Micronaut. Четверта таблиця показує роботу Wrk для Micronaut застосунку після прогріву.


Users


Latency (ms)


Req/sec


CPU, Wrk


CPU, App


Mem, Wrk


Mem, App


100


1.79


31550


330


1228


3.8


810


1000


9.53


34200


326


1245


15.5


925


5000


39.25


32010


331


1245


65


1188

Що, якби нам потрібно було параметризувати запит і додати інформацію, яку не можна передати через URL (тіло запиту чи заголовки)? Для цього потрібно написати скрипт мовою Lua і передати його у Wrk як параметр командою рядка:

wrk.method = "POST"
wrk.body   = "{'name':'John'}"
wrk.headers["Content-Type"] = "application/json"

K6

Потім перейдемо до наступного performance tool — K6. Тут вже є готовий Docker image, потрібно лише написати скрипт test.js, який описує наш тест:

export let errorRate = new Rate("errors");
 
export default function() {
  const res = http.get("http://user:8080/benchmark");
  const result = check(res, {
    "status is 200": r => r.status == 200
  });
  errorRate.add(!result);
  sleep(Math.random() * 3);
}

Це код на JavaScript, інтуїтивно зрозумілий більшості розробників. Default function() — це функція, яка викликається кожним віртуальним користувачем (virtual user або VU). У ній ми відправляємо HTTP-запит і перевіряємо статус-код, що повертається, щоб відрізнити успішний запит від неуспішного і оновити метрику «errors».

Для чого потрібен виклик sleep, який зупиняє виконання для поточного віртуального користувача на певну кількість секунд? Проведімо експеримент. Створимо Docker скрипт k6.dockerfile:

FROM grafana/k6
 ADD docker-scripts/benchmark/k6/test.js /opt/

І запустимо наш новий Docker-контейнер з командою run --vus 5000 --duration 20s /opt/test.js.

Якби ми використовували локальний k6, то команда була б k6 run, але для Docker-контейнера k6 буде зайвим. VUs — кількість віртуальних користувачів.

Якщо ми використали sleep:

  • Latency 1.64 мс.
  • 2000 запитів/сек.
  • Помилок 0%.
  • Завантаження CPU для K6 103% и 91% для застосунку.
  • Об’єм використовуваної пам’яті — 1.89 Gb для K6 и 555 Mb для застосунку.

І без sleep:

  • Latency 98.2 мс.
  • 46956 запитів/сек.
  • Помилок 0%.
  • Завантаження CPU для K6 859% и 691% для застосунку.
  • Об’єм використовуваної пам’яті — 2.39 Gb для K6 и 1.31 Mb для застосунку.

Проаналізуємо, що ми отримали. Якщо ми хочемо організувати load testing, то повинні розуміти, що реальні користувачі не бомбардують наші сервери запитами. Вони це роблять із деякою паузою.

Тому sleep — це є та пауза між запитами, але вказувати константний час для неї не дуже добре, це призведе до загальних для всіх VU періоду паузи та роботи. Тому краще використовувати Math.random(), який повертає значення від 0 до 1, щоб зробити тривалість випадковою.

Якщо ж ви хочете організувати stress testing для вашого сервера, то можна прибрати паузу і перевірити, наскільки стійка ваша програма. Ми будемо використовувати sleep()..

Може виникнути питання. А як K6 запускає JavaScript-код, якщо він сам написаний на Go? Тут розробники K6 могли використовувати такі движки як V8 або SpiderMonkey або скористатися Node.js.

Але вони пішли іншим шляхом і застосовують таку технологію як goja — це JavaScript runtime, що розробляється на Go з 2016 року. Спочатку goja підтримувала ECMAScript 5.1 код, а з 2023 року — і стандарт ECMASsript 6 (2015).

П’ята таблиця показує роботу K6 для Spring MVC застосунку після прогріву:


Users


Latency (ms)


Req/sec


CPU, K6


CPU, App


Mem, K6


Mem, App


100


1.49


61


14


13


92


323


1000


1.53


596


65


45


441


332


5000


1.64


2000


108


102


1.85


619

У цьому тестуванні ми використовували K6 у базовому варіанті, передаючи кількість користувачів та тривалість тесту через командний рядок, як і у випадку з Wrk. У той же час DSL в K6 набагато розвиненіший. За допомогою спеціального об’єкта options можна сконфігурувати наш тест, наприклад, розбивши його на етапи (stages):

export const options = {
  stages: [
    { duration: '1m', target: 500 },
    { duration: '10m', target: 5000 },
    { duration: '1m', target: 0 },
  ],
};

Тут ми спочатку протягом хвилини збільшуємо навантаження до 500 користувачів, потім протягом наступних 10 хвилин доводимо їх кількість до 5000, і потім протягом хвилини знижуємо до нуля. Якщо вам потрібне точніше налаштування, можуть стати в нагоді Сценарії, де ви вказуєте тип навантаження, кількість ітерації і т.д.

Можна налаштувати й обробку помилок. Зараз у нас будь-який запит вважається успішним, якщо сервер повернув відповідь, навіть якщо це сталося за хвилину. Але в реальній ситуації завжди є деяке граничне значення для latency. У K6 є спеціальний об’єкт thresholds, в якому можна передати ті перевірки, які потрібно виконати після завершення тесту.

Наприклад, тут ми перевіряємо, що середній час обробки запиту менше 3 секунд:

export const options = {
 
  thresholds: {
    http_req_duration: ['avg < 3000']
  }
};

Якщо ця умова буде порушена, то threshold http_req_duration буде вважатися і буде показаний як неуспішний.

Висновки

Порівняння самих performance tools буде в наступній частині. А тут я хотів би порівняти наші вебфреймворки. Усі вони успішно пройшли тестування, але із різними показниками. У варіанті зі 100 користувачами найкраще відпрацював Spring MVC, який показав на 10-20% кращі latency та кількість користувачів, ніж інші технології.

У той же час, Micronaut краще впорався у варіантах 1000/5000 користувачів, випередивши конкурентів на 30%. Але він же показав велике завантаження CPU в тестах (приблизно на 10% вище за інших учасників тестування).

Що стосується пам’яті, то Spring MVC показав кращі результати, що говорить про те, що його автори витратили чимало часу на оптимізацію в останніх версіях. Micronaut відстав на 10-15%, а Jersey і тут виявився найгіршим, програвши Spring MVC на 30-40%. Таким чином Jersey можна назвати умовним аутсайдером у наших тестах, а ось Spring MVC та Micronaut поділили п’єдестал.

Щодо прогріву, то він виявився абсолютно необхідним. Якщо ми його використовували, то latency в деяких випадках зменшувалася в 3(!) рази, а пропускна здатність збільшувалася на 30-40% в порівнянні з «холодним» режимом. У той же час прогрів ніяк не впливав на завантаження CPU і використовувану пам’ять.

👍ПодобаєтьсяСподобалось6
До обраногоВ обраному5
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі