Привет, с вами снова Егор, Tech Lead компании ИдаПроджект :) Напомню, что я занимаюсь стратегией, процессами и командами в направлении backend разработки.
Сегодня мы вместе сделаем минимальное приложение на django + DRF и проведем нагрузочное тестирование с помощью Grafana K6. Также попробуем применить кэширование в Nginx. Будем тестировать как GET-запросы, которые можно и нужно кэшировать, так и POST-запросы, которые кэшировать нельзя.
Погнали.
Методология тестирования
Глобально есть два основных вида тестирования API: функциональное и нефункциональное.
Функциональное тестирование включает в себя проверку корректности работы API в соответствии с требованиями и спецификациями. Обычно такую проверку делают с помощью unit-тестирования, методов черного и белого ящика и smoke-тестирования.
Нефункциональное тестирование включает проверку производительности, безопасности, надежности и т.д. То есть то, что не включено в бизнес-логику работы приложения. Это все делают с помощью различных видов нагрузочного тестирования, проверок безопасности работающего приложения и исходного кода, а также проверки масштабируемости инфраструктуры.
В этой статье мы займемся нефункциональным тестированием API, а именно — проведем нагрузочное тестирование приложения. Воспользуемся следующими видами нагрузочного тестирования:
Smoke test — простая проверка, что наше api работает, как планировалось.
Breakpoint test — будем повышать нагрузку до того момента, пока приложение не перестанет отвечать (более подробно про виды нагрузочного тестирования можете прочитать здесь).
Метрики соберем с помощью Grafana Alloy — это новый форк от Grafana Agent, который позволяет собирать данные из системы, состояние и потребление docker-контейнеров.
Нагрузку сгенерируем с помощью Grafana K6 (инструмент для нагрузочного тестирования веб-приложений и API; простой и мощный. Мне он нравится за вариативность настройки и возможность использования дополнений). Разместим его на отдельном сервере, но в той же подсети, что и целевой сервер. Это позволит избежать влияния на производительность нашего приложения, а расположение в той же сети позволит сократить влияние задержек сети на результат.
На какие метрики будем смотреть, и что они из себя представляют:
RPS — количество запросов в секунду. Стандартная метрика, которая показывает, сколько мы можем запросов обработать.
Потребление ресурсов сервера — будем собирать метрики по использования CPU и RAM для понимания, как растет потребление ресурсов при возрастающей нагрузке. Также будем собрать данные по I/O bound метрики по сети и по диску, поскольку это тоже частая точка отказа.
Количество ошибок приложения.
95 процентиль по метрикам от K6. http_req_duration (время ответа запроса), http_req_failed (количество ошибок) и http_reqs (количество запросов в секунду) — метрики качества RED от Grafana, которые позволяют сделать общие выводы по работе системы.
Конфигурация сервера
Конфигурация сервера для генерации нагрузки
Пишем приложение и разгоняем Django + DRF без кэширования
Для примера я взял тестовое приложение для отелей. Здесь будет всего четыре модели и одна M2M таблица:
Hotel — модель для отелей
Hotel suite — номер отеля с FK на Hotel
Customer — клиент отеля
Booking — бронирование на номер отеля с FK на Hotel suite и M2M связью с Customer(в номере могут поселиться несколько человек)
С помощью скрипта python manage.py load_db сгенерируем данные в базу данных (БД), чтобы приблизить ее к «боевому» состоянию.
С помощью SQL скрипта посмотрим на состояние нашей БД.
SELECT table_name,
pg_size_pretty(table_size) AS table_size,
pg_size_pretty(indexes_size) AS indexes_size,
pg_size_pretty(total_size) AS total_size
FROM (SELECT table_name,
pg_table_size(table_name) AS table_size,
pg_indexes_size(table_name) AS indexes_size,
pg_total_relation_size(table_name) AS total_size
FROM (SELECT ('"' || table_schema || '"."' || table_name || '"') AS table_name
FROM information_schema.tables) AS all_tables
ORDER BY total_size DESC) AS pretty_sizes;
Про API
Мы будем тестировать три API:
Получение списка отелей (размер json-файла 7 KB)
Получение всех номеров отеля и их бронирование (размер json-файла примерно 950 KB)
Создание бронирование на номер отеля
Напишем сценарий для нагрузочного тестирования
Как я уже сказал, мы воспользуемся K6 от Grafana для генерации нагрузки на приложения. Подход довольной простой: напишем один сценарий, где будут все нужные нам API. Затем настроим параметры для smoke и breakpoint-тестирования.
Базовые сценарии в K6 пишутся довольно просто: это обычная функция, в которой нужно указать методы для вызова API. Пример нашего сценария:
// Начало сценария
// Получаем список отелей
let response = requestGetAPI('/api/hotel/?format=json')
sleep(1)
let hotel_list = response.json()
// Итерируемся по списку отелей
for (const hotel of hotel_list) {
let url = `/api/hotel/${hotel.id}/?format=json`
// Получаем данные по забронированным слотам этого номера в отеле
requestGetAPI(url)
sleep(1)
}
// Бронируем номер отеля
requestPostAPI('/api/booking/', 'c41e21cb-beb9-43b6-96e2-5bf53005be1c')
sleep(1)
Для Smoke тестирования нам хватит минимальной нагрузки, это можно реализовать следующим сценарием в K6:
// SMOKE TEST
smoke_test_api: {
// Функция генерации нагрузки, их там много, почитайте документацию K6
executor: 'constant-vus',
// Количество "виртуальных пользователей"
vus: 5,
// Время выполнения теста
duration: '20s',
// Какую функцию мы запускаем
exec: 'scenario_api',
},
Теперь сделаем пять «виртуальных пользователей», которые по очереди пройдутся по всем API и в конце сделают бронирование.
Для проверки запустим наше первое тестовое нагрузочное тестирование с помощью команды docker compose -f docker-compose.load.yml run load k6 run main.js
.
Здесь уже можно выявить, какие у нас показатели:
По RPS параметр https_reqs равен два запроса в секунду
По метрикам качества RED: http_req_duration 95 персентиль равен 1.5с, а у параметра http_req_failed равен 0%.
Закрепим это как базовые показатели, от которых будем отталкиваться во время тестов.
На графике ниже можно увидеть где «ломается» производительность системы, то есть при каком RPS начинаются ошибки. Тут видно, что при 100 RPS полезли ошибки, но по факту они появились еще раньше, поскольку могли прийти по timeout-операции.
Дальше мы все время будем разбирать подобные графики, пользуемся вот этим дашбордом для grafana.
Для отправки данных в grafana используем механизм Prometheus remote write. Он пока что находится в экспериментальном режиме, и хотя работает более менее оптимально, все же еще есть баги с показателями, которые иногда разнятся с результатами от K6.
Последовательность запросов отображена ниже. Запросы из K6 в nginx по факту имеют межсерверный характер. А те, что внутри сервера, по факту идут по локальной сети.
Проведем первое нагрузочное тестирование
Нам будут нужны два сервера: один для генерации нагрузки, а второй целевой, который примет эту нагрузку. Для более точного измерения сервера будут находится в одной подсети, то есть максимально близко друг к другу, чтобы влияние на сетевое взаимодействие было минимальным. Будем делать breakpoint-тестирование, потихоньку увеличивая нагрузку, пока не увидим деградацию приложения.
Вот наша конфигурация (в течении пяти минут нарастим количество виртуальных пользователей с 0 до 1000):
breakpoint_test_api: {
executor: 'ramping-arrival-rate',
preAllocatedVUs: 100,
maxVUs: 1000,
stages: [
{duration: '5m', target: 1000},
],
exec: 'scenario_api',
},
Что получилось в итоге:
По RPS параметр https_reqs равен 16 запросов в секунду.
По метрикам качества RED: http_req_duration 95 персентиль равен 1 минуте, а у параметра http_req_failed — 70%. Если перевести на человеческий язык, то 95% запросов ожидали ответа около минуты (что по факту — отсечка для ошибки timeout), поэтому удалось обработать только 30% запросов, остальные получили timeout и были сброшены.
На графике в Grafana видно, что мы действительно уперлись в потолок на 15 RPS и начали получать большое количество ошибок. Кроме того, сильно отличаются цифры показателей (например, процент ошибок), так как вывод данных в prometheus идет иногда с багами.
Что касается сервера, тут тоже все предсказуемо. Все CPU утилизировал backend, и по логике большую нагрузку дала как раз генерация данных для получение всех номеров отеля и их бронирование, ведь на каждый запрос генерировался json с 1 MB данных.
Добавляем кэширование
В большинстве случаев в django используются два вида кэширования.
Используемый для локальных оптимизаций — например, lru_cache. Чаще всего такие решения кеширования реализуются в виде словаря и протухают после перезагрузки приложения.
Хранение кэша в in-memory БД — например, redis. Это более распространенный вид кеширования, который часто используется на большинстве проектов. Мы будем использовать кеширование в redis. Добавляется он довольно просто; покажу на примере добавления в функционал выдачи списка отелей:
@method_decorator(cache_page(60 * 10))
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
На простых запросах (без сложной или персонализированной фильтрации) и при маленьком количестве данных такое кэширование не показывает значительного выигрыша по производительности, поскольку срабатывает после полного прохождения запроса по всем middleware. По факту мы экономим только на обращении в БД. Но если у нас есть сложные фильтры, I/O bound операции, сложный расчет или преобразование данных, то кэширование кратно увеличивает производительность.
На диаграмме видно, что мы будем обращаться к БД лишь один раз для GET запроса, и большая нагрузка будет распространятся как на redis.
Проверим, насколько лучше стали результаты в базовом сценарии:
По RPS параметр https_reqs равен 81 запросов в секунду. По факту показатель улучшился на 506%.
По метрикам качества RED: http_req_duration 95 персентиль равен 11 секунд, а у параметра http_req_failed равен 0%. Но по факту это неправильно, так как есть другая метрика dropped_iterations, которая указывает сколько итераций сбросилось из-за отсутствия производительности системы.
По ресурсам снова видно, что на бэкенд пришлась огромная нагрузка, а малая часть упала на redis. Почему так произошло?
Посмотрим в профилировщике, на что тратится CPU при обработке и генерации запроса. Тут можно заметить, что у нас ощутимо тратится время на обращение к redis, но так как отрабатывает и сам django, то много уходит и на обработку запроса. Поэтому даже если мы кэшируем все в redis, то из-за обработки запроса через backend нет ощутимого улучшения производительности (если кэшируем простые запросы).
Добавляем кеш в Nginx
Уточню — это простая и базовая конфигурация для кэширования в Nginx. Для production-решения нужно добавить модуль ngx_http_limit_req_module и proxy_cache_lock.
proxy_cache_path /var/lib/nginx/api_cache levels=1:2 keys_zone=API_CACHE:15m max_size=5g inactive=60m use_temp_path=off;
location /api/ {
proxy_cache API_CACHE;
proxy_cache_methods GET;
proxy_cache_key "$request_uri|$request_method";
proxy_cache_valid 200 15m;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
add_header X-Cache-Status $upstream_cache_status;
include backend;
}
На этом этапе мы будем кэшировать ответ в nginx и лишь один раз обратимся в бэкенд на GET-запрос.
Посмотрим, что изменилось:
По RPS параметр https_reqs равен 363 запроса в секунду.
По метрикам качества RED: http_req_duration 95 персентиль равен 1 минуте, а у параметра http_req_failed — 83%.
Судя по метрикам RED, ничего особо не изменилось. Почему? Потому что возникла проблема с «прогревом кеша» из-за довольно большого наплыва запросов. Gunicorn worker просто не успевает отдавать вовремя ответ, который потом будет закэширован. Поэтому все новые запросы будут идти мимо кэша и еще раз попадать в gunicorn worker для обработки запроса.
С этой проблемой можно бороться двумя способами:
Ограничение обращения в backend с помощью настройки параметров burst в nginx. Про это есть хорошая статья от RNDS
Прогреть кэш заранее с помощью smoke тестирования, а потом уже запускать breakpoint тестирование.
Мы все же рассмотрим результаты, которые получились «на холодную» (без прогревания кеша).
По ресурсам заметно, что у бэкенда была довольно высокая нагрузка в первую минуту, и потом все пошло на спад. Делаем вывод: кэш работает и снимает значительную нагрузку с бэкхенда, но все же есть довольно много ошибок с timeout-запросами, поэтому можно еще подкрутить настройки и добиться лучших показателей.
Прогрев кеша
Используем дополнительный stage в нагрузочном тестировании: в течении одной минуты будем генерировать итерации, а в оставшееся время — высокую нагрузку.
breakpoint_test_api: {
executor: 'ramping-arrival-rate',
preAllocatedVUs: 100,
maxVUs: 10000,
stages: [
{duration: '1m', target: 10},
{duration: '5m', target: 1000},
],
exec: 'scenario_api',
}
По RPS параметр https_reqs равен 734 запроса в секунду.
По метрикам качества RED: http_req_duration 95 персентиль равен 58 секунд, а у параметра http_req_failed — 96%. Выглядит так себе, но по факту мы за то же время обработали в два раза больше запросов, соответственно, и RPS вырос в два раза.
Глобально на сам сервер с backend было 2 пика нагрузки, которые пришлись только на инициализацию кеша. В остальном была довольно большая нагрузка на сеть.
POST запросы
Отдельно проверим производительность POST-запросов на бронирование. Их нельзя кэшировать, поэтому мы можем лишь оптимизировать функционал и подобрать правильную конфигурацию самой БД.
По RPS параметр https_reqs равен 384 запроса в секунду.
По метрикам качества RED: http_req_duration 95 персентиль равен 14 секундам, а у параметра http_req_failed — 55%. В принципе неплохо — с учетом базовых настроек Nginx и PostgreSQL.
На графиках видно, что ошибки начинают появляться примерно со 100 RPS.
По нагрузке на сервер видно, что у нас снова backend забрал 70% мощности CPU, но и БД была под ощутимой нагрузкой (для стандартной конфигурации).
Выводы
На этом все!
Мы прошлись по базовому использованию K6 и посмотрели, как ведет себя самое простое и базовое приложение на django под "большой" нагрузкой. Кроме того, посмотрели как работают разные подходы к кэшированию данных.
Наши итог — прописная истина, что кэшировать данные надо как можно раньше, а не в конце :) Спасибо за внимание!